package jvm.jtacck;

import static jvm.jtacck.Util.logger;

import java.io.EOFException;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;

import jvm.jtacck.db.AwesomeCoreHandler;
import jvm.jtacck.db.records.CheckListPair;
import jvm.jtacck.db.records.ClientSite;
import jvm.jtacck.db.records.Courier;
import jvm.jtacck.db.records.ShippingInformation;
import jvm.jtacck.serials.Barcode;
import jvm.jtacck.serials.GPS;
import jvm.jtacck.serials.LoginInfo;
import jvm.jtacck.serials.Route;
import jvm.jtacck.serials.ShippingList;

/**
 * @author Jesse Jeng
 * 
 *         Default ports: Port 4000 = Push route to Android phones; never
 *         listens Port 9001 = Handle requests from Android phones; always
 *         listens
 * 
 */
public final class AndroidServer extends Thread {
    /**
     * Boolean for controlling all threads within the AndroidServer
     */
    private volatile boolean threading;
    private int pushPort;
    private int listenPort;

    /**
     * A map structure that keeps track of all the threads associated with
     * asynchronous update of courier routes
     */
    private HashMap<String, PushSocketThread> pushMap;

    /**
     * A map structure that keeps track of all the threads associated with
     * handling requests from couriers
     */
    private HashMap<String, ListenSocketThread> listenMap;

    /**
     * A list that keeps track of all the socket listeners
     */
    private LinkedList<SocketListener> listenerList;

    /**
     * A list that keeps track of all the socket threads regardless of their
     * type, used to force close all threads
     */
    private LinkedList<SocketThread> threadList;

    /**
     * For local testing purposes
     * 
     * @param args
     *            Command line arguments
     * @throws InterruptedException
     */
    public static void main(String args[]) throws InterruptedException {
        AndroidServer s = new AndroidServer(4000, 9001);
        s.start();
        s.join();
    }

    /**
     * Constructs a multithreaded AndroidServer object that handles socket
     * communications with the Android phone.
     * 
     * @param pushPort
     *            The port used for asynchronous updates of courier routes.
     *            After authentication, the Android phone will always be
     *            listening on this port where the AndroidServer will be sending
     *            updated routes through this port. DEFAULT = 4000
     * @param listenPort
     *            The port used for handling all requests from the Android
     *            phone. After authentication, the AndroidServer will always be
     *            listening on this port where the Android phone will be sending
     *            requests through this port. DEFAULT = 9001
     */
    public AndroidServer(int pushPort, int listenPort) {
        this.threading = true;
        this.pushMap = new HashMap<String, PushSocketThread>();
        this.listenMap = new HashMap<String, ListenSocketThread>();
        this.listenerList = new LinkedList<SocketListener>();
        this.threadList = new LinkedList<SocketThread>();
        this.pushPort = pushPort;
        this.listenPort = listenPort;
        AwesomeCoreHandler.setServer(this);
    }

    /*
     * (non-Javadoc)
     * 
     * @see java.lang.Thread#run()
     * 
     * Creates two listener threads to listen for socket connections on the two
     * ports
     */
    public synchronized void run() {
        try {
            SocketListener pushSocketListener = new SocketListener(pushPort);
            SocketListener requestSocketListener = new SocketListener(
                    listenPort);
            listenerList.add(pushSocketListener);
            listenerList.add(requestSocketListener);
            pushSocketListener.start();
            requestSocketListener.start();
            logger.info("Listening to port " + listenPort
                    + " + Pushing on port " + pushPort);
        } catch (Exception e) {
            stopAndroidServer();
            logger.info("Failure in starting the AndroidServer");
        }
    }

    /**
     * Stops the AndroidServer and all created threads
     */
    public void stopAndroidServer() {
        threading = false;
        for (SocketListener listener : listenerList) {
            listener.stopListener();
        }
        logger.info("Server stopped");
    }

    /**
     * Updates the route to the associated courier's phone
     * 
     * @param username
     *            Courier's username for login
     * @param route
     *            The route to be updated to the phone
     */
    public void pushRoute(String username, Route route) {
        PushSocketThread thread = pushMap.get(username);
        if (thread != null) {
            thread.queueRoute(route);
        }
    }

    /**
     * @author Jesse Jeng
     * 
     *         Thread class that listens for socket connections and spawns the
     *         appropriate type of threads base on port connected
     * 
     */
    private class SocketListener extends Thread {
        private ServerSocket serverSocket;
        private int port;

        /**
         * Constructor for the SocketListener
         * 
         * @param port
         *            Port to listen on; listen port or request port
         */
        public SocketListener(int port) throws IOException {
            super("SocketListener - Port: " + port);
            this.port = port;
            serverSocket = new ServerSocket(port);
        }

        /*
         * (non-Javadoc)
         * 
         * @see java.lang.Thread#run()
         * 
         * Listens for socket connections and spawns socket threads to handle
         * accepted connections
         */
        public void run() {
            try {
                while (threading) {
                    SocketThread thread = port == pushPort ? new PushSocketThread(
                            serverSocket.accept()) : new ListenSocketThread(
                            serverSocket.accept());

                    logger.fine("Accepted connection on port " + port);
                    threadList.add(thread);
                    thread.start();
                }
            } catch (SocketException e) {
                logger.info("Server Socket closed on port: " + port);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        /**
         * Stops the listener and all socket threads spawned by the listener
         */
        public void stopListener() {
            for (SocketThread thread : threadList) {
                thread.closeSocket();
            }
            try {
                serverSocket.close();
            } catch (IOException e) {
                System.err.println("stopListener - serverSocket.close()");
            }
        }
    }

    /**
     * @author Jesse Jeng Socket thread class, extended by specific types of
     *         socket threads that behaves differently depending on the port
     *         they operate on
     * 
     */
    protected class SocketThread extends Thread {
        protected Socket socket;
        protected String username;
        protected ObjectOutputStream oos;
        protected ObjectInputStream ois;
        protected boolean socketClosed;
        protected boolean authenticated;
        protected volatile long time;

        /**
         * Constructor for socket thread
         * 
         * @param socket
         *            Socket accepted from the SocketListener
         */
        public SocketThread(Socket socket) {
            super("SocketThread - Port(" + socket.getLocalPort() + ")");
            this.socket = socket;
            this.username = "Unknown";
            this.authenticated = false;
            this.time = System.currentTimeMillis();
            try {
                this.socket.setSoTimeout(300000);
                oos = new ObjectOutputStream(socket.getOutputStream());
                oos.flush();
                ois = new ObjectInputStream(socket.getInputStream());
            } catch (IOException e) {
                logger.info("Can't get input or output stream");
                this.socket = null;
            }
            socketClosed = false;
        }

        /**
         * Helper method for reading objects from the input stream, logs all
         * incoming objects and the username associated with the stream.
         * 
         * @return The object from the input stream
         */
        protected Object readObject() {
            String err = this.getName() + " - Read object failed due to (";
            try {
                Object input = ois.readObject();
                logger.finer("Read ("
                        + (input == null ? null : input.getClass())
                        + ") from user: " + username);
                return input;
            } catch (SocketTimeoutException e) {
                err += "SocketTimeoutException";
            } catch (ClassNotFoundException e) {
                err += "ClassNotFoundException)";
            } catch (EOFException e) {
                err += "EOFException)";
            } catch (SocketException e) {
                err += "SocketException)";
            } catch (IOException e) {
                err += "IOException)";
            }
            connectionLost(err);
            return null;
        }

        /**
         * Helper method for sending objects through the output stream, logs all
         * outgoing objects and the username associated with them
         * 
         * @param output
         *            The object to be sent over the output stream
         */
        protected void writeObject(Object output) {

            String err = this.getName() + " - Write object failed due to (";
            try {
                oos.writeObject(output);
                oos.flush();
                logger.finer("Pushed ("
                        + (output == null ? null : output.getClass())
                        + ") to user: " + username);
                return;
            } catch (EOFException e) {
                err += "EOFException)";
            } catch (SocketException e) {
                err += "SocketException)";
            } catch (IOException e) {
                err += "IOException)";
            }
            connectionLost(err);
        }

        /**
         * Logs given error string and closees the socket
         * 
         * @param err
         *            Error string to log
         */
        protected void connectionLost(String err) {
            logger.info(err + " - Connection lost for user: " + username);
            closeSocket();
        }

        /**
         * Authenticates the socket against the connected Android phone by first
         * reading in a LoginInfo object that contains username and password. It
         * then checks the information against the database. Sends back a String
         * if authenticated successfully or Exception if the LoginInfo is
         * incorrect.
         */
        protected void authenticate() {
            // End authentication immediately if something went wrong with
            // socket initialization
            if (socket == null) {
                return;
            }
            String err = "Failed to authenticate due to (";
            Object input = readObject();
            if (input instanceof LoginInfo) {
                LoginInfo info = (LoginInfo) input;
                username = info.username;
                if (pushMap.containsKey(username)
                        && listenMap.containsKey(username)) {
                    writeObject(new Exception("Already logged in"));
                    err += "Already logged in)";
                } else {
                    try {
                        authenticated = AwesomeCoreHandler.getUserInfo(
                                username, info.password) != null;
                    } catch (IllegalStateException e) {
                        err += "No database connection)";
                    }
                    if (authenticated) {
                        writeObject("Success");
                    } else {
                        writeObject(new Exception("Failure"));
                        err += "Username does not exist in database)";
                    }
                }
            } else {
                writeObject(new Exception("Not LoginInfo"));
                err += "Not LoginInfo)";
            }
            if (!authenticated) {
                connectionLost(err);
            }
        }

        /**
         * Closes the socket and all IO streams
         */
        public void closeSocket() {
            authenticated = false;
            try {
                if (!socket.isOutputShutdown()) {
                    socket.shutdownOutput();
                }
                if (!socket.isInputShutdown()) {
                    socket.shutdownInput();
                }
                if (!socket.isClosed()) {
                    socket.close();
                    logger.finer(getClass() + " stopped on port: "
                            + socket.getLocalPort() + " for user: " + username);
                }
            } catch (IOException e) {
                logger.info("Error when closing socket");
            }
            threadList.remove(this);
        }
    }

    /**
     * @author Jesse Jeng
     * 
     *         Thread class for handling pushing objects to the Android phone.
     *         This socket thread does not listen/read any inputs from the
     *         Android after authentication. It simply pushes any updates from
     *         the database through the output stream of the socket
     * 
     */
    private class PushSocketThread extends SocketThread {
        /**
         * A blocking queue that queues up new routes from the database
         */
        private BlockingDeque<Route> routeQueue;

        public PushSocketThread(Socket socket) {
            super(socket);
            routeQueue = new LinkedBlockingDeque<Route>();
        }

        /*
         * (non-Javadoc)
         * 
         * @see java.lang.Thread#run()
         * 
         * Execution method of the PushSocketThread
         */
        public void run() {
            // Authenticates this socket thread
            authenticate();
            if (authenticated) {
                // Stores itself in a map that's keeping track of all the logged
                // in couriers and the threads associated with them
                pushMap.put(username, this);
                pushCheckListPair();
                pushInitialRoute();
                while (authenticated && threading) {
                    try {
                        writeObject(routeQueue.take());
                    } catch (InterruptedException e) {
                        connectionLost("PushSocketThread interrupted");
                    }
                }
                pushMap.remove(username);
            }
            closeSocket();
        }

        /**
         * @param route
         *            Route object to be queued
         */
        public void queueRoute(Route route) {
            routeQueue.offer(route);
        }

        /**
         * Sends the beginning/ending shift check list to the Android
         */
        public void pushCheckListPair() {
            if (authenticated) {
                String err = "Failed to push CheckListPair due to (";
                try {
                    CheckListPair clp = AwesomeCoreHandler.getCheckLists();
                    if (clp != null) {
                        writeObject(clp);
                    } else {
                        writeObject(new Exception("No CheckListPair"));
                        err += "No CheckListPair)";
                        connectionLost(err);
                    }
                } catch (IllegalStateException e) {
                    err += "No database connection)";
                    connectionLost(err);
                }
            }
        }

        /**
         * Sends the initial route to the Android
         */
        public void pushInitialRoute() {
            if (authenticated) {
                String err = "Failed to push initial route due to (";
                try {
                    Route route = AwesomeCoreHandler
                            .getRouteWithPackages(username);
                    if (route != null) {
                        writeObject(route);
                    } else {
                        writeObject(new Exception("No route"));
                        err += "No route)";
                        connectionLost(err);
                    }
                } catch (IllegalStateException e) {
                    err += "No database connection)";
                    connectionLost(err);
                }
            }
        }
    }

    /**
     * @author Jesse Jeng
     * 
     *         Thread that handles requests sent from the Android, it is always
     *         listening for requests. Sends back objects depending on what was
     *         received from the Android
     * 
     */
    private class ListenSocketThread extends SocketThread {
        public ListenSocketThread(Socket socket) {
            super(socket);
        }

        /*
         * (non-Javadoc)
         * 
         * @see java.lang.Thread#run()
         * 
         * Execution method of ListenSocketThread
         */
        public void run() {
            // Authenticates this socket/thread
            authenticate();
            if (authenticated) {
                // Stores itself in a map that's keeping track of all the logged
                // in couriers and the threads associated with them
                listenMap.put(username, this);
                while (authenticated && threading) {
                    Object input = readObject();
                    if (input instanceof Barcode) {
                        updateBarcode((Barcode) input);
                    } else if (input instanceof GPS) {
                        updateGPS((GPS) input);
                    } else if (input instanceof ClientSite) {
                        sendShippingList((ClientSite) input);
                    } else {
                        if (authenticated) {
                            writeObject(new Exception("Unknown"));
                        }
                    }
                }
                // Because the push thread is being blocked by the blocking
                // queue, this listen thread needs to be the one interrupting
                // the block. The two threads are linked by a common username
                SocketThread t = pushMap.get(username);
                if (t != null) {
                    t.interrupt();
                }
                listenMap.remove(username);
            }
            closeSocket();
        }

        /**
         * Updates the database that the status of the given barcode(package)
         * has been picked up by the associated courier
         * 
         * @param input
         *            Barcode to be updated
         */
        private void updateBarcode(Barcode input) {
            String barcode = input.barcode;
            if (AwesomeCoreHandler.pickedUp(barcode, username)) {
                writeObject("Success");
            } else {
                writeObject(new Exception("Failure"));
            }
        }

        /**
         * Updates GPS location in the database for the associated courier
         * 
         * @param input
         *            GPS object that contains latitude and longitude
         */
        private void updateGPS(GPS input) {
            double latitude = input.latitude;
            double longitude = input.longitude;
            AwesomeCoreHandler.updateGPS(username, latitude, longitude);
            writeObject("Success");
        }

        /**
         * Gets a list of shipping information from the database associated with
         * the given client site and then sends to the Android
         * 
         * @param input
         *            ClientSite object that will be used to determine the
         *            associated shipping list
         */
        private void sendShippingList(ClientSite input) {
            List<ShippingInformation> shippingList = AwesomeCoreHandler
                    .specimenFrom(input);
            if (shippingList != null) {
                writeObject(new ShippingList(shippingList));
            } else {
                writeObject(new Exception("Failure"));
            }
        }
    }
}
